Method8
Data model Schema implementation · working note

The smallest possible structure

The whole product fits in three long tables.

Programs, sections, artefacts, fields, options, assessments, the maturity model, reports — and the data people actually enter — are all the same shape: a thing, its properties, and how it connects to other things. Model that shape once and the schema stops needing migrations. New content is new rows, never new columns.

node tall

One row = one thing. The tree of everything.

iduuid pk
kindtext
parent_idfk → node
positionint
labeltext
attr tall

One row = one property. Every field that would be a column.

node_idfk → node
keytext
valuetext
{{ valTypeField }}{{ valTypeNote }}
pk (node_id, key)
link tall

One row = one relationship that isn't the tree.

src_idfk → node
reltext
dst_idfk → node
positionint
labeltext · null

Three relationships do all the work. parent_id nests the tree (program → section → artefact → field → option, and a data row → its artefact). attr holds everything else a thing knows about itself. link carries the few cross-tree edges — a dashboard tile takes_from another stage, a tool slot uses_assessment, a person has_access to a node.

Watch real content map in

Pick something from the builder — see the exact rows it becomes.

In the app it looks like
{{ ex.badge }}

{{ ex.title }}

{{ ex.sub }}

{{ l.k }} {{ l.v }}
node {{ ex.nodeCount }}
idkindparentposlabel
{{ r.id }} {{ r.kind }} {{ r.parent }} {{ r.pos }} {{ r.label }}
attr {{ ex.attrCount }}
node_idkeyvalue
{{ r.id }} {{ r.key }} {{ r.value }}
link {{ ex.linkCount }}
srcreldstlabel
{{ r.src }} {{ r.rel }} {{ r.dst }} {{ r.label }}

{{ ex.note }}

Why long, not wide

Built to change weekly without a migration.

New content = INSERT

A new field on an artefact, a sixth maturity level, a whole new stage type — all rows. No ALTER TABLE, no deploy, no downtime. Authors change the schema from the builder UI.

Schema and data, same shape

A field definition and a filled-in cell are both nodes/attrs. One set of read, write, version and access paths serves the builder, the live document and every report.

Versioning is free

Add valid_from / valid_to to each table and history is a query. Compare two published schemas, or replay what a report saw on a given date — without copies.

The cost of going this narrow — and how it's paid

EAV trades a clean physical schema for query effort. Worth naming up front.

Reads need pivoting. Reconstructing a wide row means joining attr by key. Hide it behind one node_full view (or JSON aggregate) so app code never sees the EAV.

The DB won't type values. Either keep one text value + a typed-column variant, or store value_num / value_date / value_ref. The field's own type attr drives casting and validation in the app.

Indexing is targeted. Composite index on (node_id, key) and on parent_id covers nearly every access pattern; add a partial index per hot key if a report needs it.

Integrity lives in the app. "A field must have a type", "a select must have options" are validated against the kind on write — the same place the builder already enforces them.

schema.sql · the entire data layer
-- everything the product stores
create table node (
  id        uuid primary key default gen_random_uuid(),
  kind      text not null,        -- program · section · artefact · field · option · record · …
  parent_id uuid references node(id) on delete cascade,
  position  int  not null default 0,
  label     text
);

create table attr (
  node_id uuid not null references node(id) on delete cascade,
  key     text not null,
  value   text,
  primary key (node_id, key)
);

create table link (
  src_id   uuid not null references node(id) on delete cascade,
  rel      text not null,        -- takes_from · uses_assessment · has_access
  dst_id   uuid not null references node(id) on delete cascade,
  position int  not null default 0,
  label    text,
  primary key (src_id, rel, dst_id)
);

create index on node (parent_id, position);
create index on attr (key, value);